your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import type { EventData } from '$lib/cards/social/EventCard';
3 import { getCDNImageBlobUrl } from '$lib/atproto';
4 import { Avatar as FoxAvatar, Badge } from '@foxui/core';
5 import Avatar from 'svelte-boring-avatars';
6
7 let { data } = $props();
8
9 let events: EventData[] = $derived(data.events);
10 let did: string = $derived(data.did);
11 let hostProfile = $derived(data.hostProfile);
12
13 let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did);
14 let hostUrl = $derived(
15 hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}`
16 );
17
18 function formatDate(dateStr: string): string {
19 const date = new Date(dateStr);
20 const options: Intl.DateTimeFormatOptions = {
21 weekday: 'short',
22 month: 'short',
23 day: 'numeric'
24 };
25 if (date.getFullYear() !== new Date().getFullYear()) {
26 options.year = 'numeric';
27 }
28 return date.toLocaleDateString('en-US', options);
29 }
30
31 function formatTime(dateStr: string): string {
32 return new Date(dateStr).toLocaleTimeString('en-US', {
33 hour: 'numeric',
34 minute: '2-digit'
35 });
36 }
37
38 function getModeLabel(mode: string): string {
39 if (mode.includes('virtual')) return 'Virtual';
40 if (mode.includes('hybrid')) return 'Hybrid';
41 if (mode.includes('inperson')) return 'In-Person';
42 return 'Event';
43 }
44
45 function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' {
46 if (mode.includes('virtual')) return 'cyan';
47 if (mode.includes('hybrid')) return 'purple';
48 if (mode.includes('inperson')) return 'amber';
49 return 'secondary';
50 }
51
52 function getLocationString(locations: EventData['locations']): string | undefined {
53 if (!locations || locations.length === 0) return undefined;
54
55 const loc = locations.find((v) => v.$type === 'community.lexicon.location.address');
56 if (!loc) return undefined;
57
58 const flat = loc as Record<string, unknown>;
59 const nested = loc.address;
60
61 const locality = (flat.locality as string) || nested?.locality;
62 const region = (flat.region as string) || nested?.region;
63
64 const parts = [locality, region].filter(Boolean);
65 return parts.length > 0 ? parts.join(', ') : undefined;
66 }
67
68 function getThumbnail(event: EventData): { url: string; alt: string } | null {
69 if (!event.media || event.media.length === 0) return null;
70 const media = event.media.find((m) => m.role === 'thumbnail');
71 if (!media?.content) return null;
72 const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' });
73 if (!url) return null;
74 return { url, alt: media.alt || event.name };
75 }
76
77 function getRkey(event: EventData): string {
78 return event.url.split('/').pop() || '';
79 }
80
81 let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`);
82</script>
83
84<svelte:head>
85 <title>{hostName} - Events</title>
86 <meta name="description" content="Events hosted by {hostName}" />
87 <meta property="og:title" content="{hostName} - Events" />
88 <meta property="og:description" content="Events hosted by {hostName}" />
89 <meta name="twitter:card" content="summary" />
90 <meta name="twitter:title" content="{hostName} - Events" />
91 <meta name="twitter:description" content="Events hosted by {hostName}" />
92</svelte:head>
93
94<div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12">
95 <div class="mx-auto max-w-4xl">
96 <!-- Header -->
97 <div class="mb-8">
98 <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl">
99 Upcoming events
100 </h1>
101 <div class="flex items-center gap-2 mt-4">
102 <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span>
103 <a
104 href={hostUrl}
105 target={hostProfile?.hasBlento ? undefined : '_blank'}
106 rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'}
107 class="flex items-center gap-1.5 hover:underline"
108 >
109 <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" />
110 <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span>
111 </a>
112 </div>
113 </div>
114
115 {#if events.length === 0}
116 <p class="text-base-500 dark:text-base-400 py-12 text-center">No events found.</p>
117 {:else}
118 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
119 {#each events as event (event.url)}
120 {@const thumbnail = getThumbnail(event)}
121 {@const location = getLocationString(event.locations)}
122 {@const rkey = getRkey(event)}
123 <a
124 href="{actorPrefix}/e/{rkey}"
125 class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group block overflow-hidden rounded-xl border transition-colors"
126 >
127 <!-- Thumbnail -->
128 {#if thumbnail}
129 <img
130 src={thumbnail.url}
131 alt={thumbnail.alt}
132 class="aspect-square w-full object-cover"
133 />
134 {:else}
135 <div
136 class="bg-base-100 dark:bg-base-900 aspect-square w-full [&>svg]:h-full [&>svg]:w-full"
137 >
138 <Avatar
139 size={400}
140 name={rkey}
141 variant="marble"
142 colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
143 square
144 />
145 </div>
146 {/if}
147
148 <!-- Content -->
149 <div class="p-4">
150 <h2
151 class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-1 leading-snug font-semibold"
152 >
153 {event.name}
154 </h2>
155
156 <p class="text-base-500 dark:text-base-400 mb-2 text-sm">
157 {formatDate(event.startsAt)} · {formatTime(event.startsAt)}
158 </p>
159
160 <div class="flex flex-wrap items-center gap-2">
161 {#if event.mode}
162 <Badge size="sm" variant={getModeColor(event.mode)}
163 >{getModeLabel(event.mode)}</Badge
164 >
165 {/if}
166
167 {#if location}
168 <span class="text-base-500 dark:text-base-400 truncate text-xs">{location}</span>
169 {/if}
170 </div>
171
172 {#if event.countGoing && event.countGoing > 0}
173 <p class="text-base-500 dark:text-base-400 mt-2 text-xs">
174 {event.countGoing} going
175 </p>
176 {/if}
177 </div>
178 </a>
179 {/each}
180 </div>
181 {/if}
182 </div>
183</div>